Skip to content

codegen: the facts' interval of an int32 local reaches the range proofs, and a key they bound to int32 is lowered as its i32 - #14453

Merged
proggeramlug merged 32 commits into
mainfrom
perf/int32-local-ranges
Nov 11, 2026
Merged

proggeramlug merged 32 commits into
mainfrom
perf/int32-local-ranges

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Second PR of the typed-set lane (on top of #14450, which typed set$sig(i32[-128,255], i32[-8,7])). The typed body
still ran its integer work in doubles:

const old = this.at(square);                                      // facts: old is i32 [-8,7]
this.hashA ^= this.zobristA[(old + 6) * 128 + square] ?? 0;        // key: vaddsd, vmulsd, vaddsd, call normalize
this.board[square] = piece;                                        // at rest: key i32 → double → fcmp ×3 → fptosi

wpfacts proves an interval for each of old, piece and square, but exported only "this local is an int32"
(ElisionFacts::int32_inputs), and a typed body seeded only parameters whose interval starts at 0. Codegen's range
proofs (range_facts::int_range_expr) therefore knew nothing about a signed int32 local, so:

  • a key built from such locals was no proven integer index: a double chain, then
    perry_array_index_normalize_i32 (a call) in the runtime-key arm;
  • an at-rest element site keyed by a signed I32 parameter took the double-key form (form=f64);

What changes

  • The interval is exported (ElisionFacts::int32_ranges, from BindingFacts::int_range; the perry driver
    fills it beside int32_inputs). No wpfacts change: the fact existed.
  • A proven int32 let reads its interval (range_facts::facts_local_range): the last resort of
    int_range_for_local, after every local proof (a loop fact, an alias, a constant, the parameter summary). The
    interval is whole-life (every value the local ever holds), so it needs no dominating guard. Only a let of
    FnCtx::facts_int32_locals (not boxed, no module global), in a body that uses the facts.
  • A typed body's I32 parameters start with their signature interval (signature_entry::param_range_facts,
    seeded into FnCtx::int_range_facts under a scope no loop ends): T1's nonnegative_params, generalised to any
    sign. Written parameters are demoted by plan selection and seed nothing.
  • A key the range proofs bound to int32 is lowered as its i32 (expr/int32_key.rs): the runtime-key element
    read (index_get) and the runtime-key store (index_set) lower such a key with lower_expr_as_i32 (its i32
    chain, exact because the chain's result is in int32 and below 2^53 at every step) and hand it to the canonical
    split as a native key. The split's unsigned bound (N10) declines a negative key onto the arm that reads the
    original value, as for any native key. At-rest sites take the int32 form through the same range
    (rest_elems::int32_key already asked int_range_expr).

Switch, probe, witness, sabotage

  • PERRY_FACTS_LOCAL_RANGES=0 (off, false): none of the above (main's lowering). Compile-cache input
    (compile_env.rs, test local_ranges_switch_is_a_cache_input).
  • Probes: PERRY_REST_DIAG=1 shows the at-rest forms (form=f64 → form=i32 / form=rep); # rep … range= in
    PERRY_FACTS_DUMP is the interval read.
  • Check mode (PERRY_VERIFY_FACTS=1): the store witness of a proven int32 let
    (facts_int32_locals::witness_exact) also tests that the stored value lies in the interval the range proofs
    read ("int32 input", exit 86). A typed parameter's interval is witnessed at every typed site and at the boxed
    entry (P2's witness_value, unchanged).
  • S0 arm local-range-negative (PERRY_FACTS_SABOTAGE=localrange: the interval claimed is its lower bound
    alone): correct rc 0, sabotaged rc 86, predicate int32 input.

Results

Chess (qb10, instructions:u M, one build, on vs PERRY_FACTS_LOCAL_RANGES=0, twice each, identical runs,
checksums and PV equal in every arm). Build y1 = c96e5d4, the PR's code on main 7523aa7 (#14450); the PR head adds the merge of main
6d3cd78, not re-measured:

workload on off (= main) change
search 250000 19 869.0 20 762.7 −4.30%
perft 4 467.5 490.7 −4.73%
evaluate 100000 1 429.9 1 487.9 −3.90%
legal 10000 678.7 700.5 −3.11%

Before merging main (build x2, on main e4f004e + #14450's branch): 20 466.3 / 467.5 / 1 429.9 / 678.7 vs
21 360.0 / 490.7 / 1 487.8 / 700.5 (−4.18 / −4.73 / −3.89 / −3.11%). The parts, on that base: the interval alone
(no key lowering, build x1) −1.12 / −1.22 / 0 / −0.83%; the key lowering adds the rest, and all of evaluate.

Per function, search 250000 (perf record -e instructions:u, --debug-symbols builds, M = sample share × total):

function on off
make$sig (with the inlined set$sig) 4 078.3 4 333.6
unmake$sig 784.2 1 357.9
evaluate$sig 2 384.5 2 509.9
perry_array_index_normalize_i32 81.6 224.6
total 19 903.7 20 794.8

In set's body the Zobrist key is now shl $7, old; lea 0x300(square, old) in place of vaddsd; vmulsd; vaddsd; call perry_array_index_normalize_i32, and the board read is cmp $0x80, square; jae cold; load in place of the
double-key test. At-rest forms (PERRY_REST_DIAG): set$sig read form=rep, store form=i32 (off: both
form=f64); make$sig's at(captureSquare) form=rep; unmake$sig 4 of its 5 stores form=i32 (off: 2).
In evaluate the normalizer is no longer sampled (9.4 M off): its keys built from int32 lets are i32 chains.

Peak RSS (/usr/bin/time %M, KiB, 5 runs each, y1): search 26 880 ×5 both arms; perft 19 456 (one 19 200
off); evaluate 19 456 (one 19 200 off); legal 19 456 (one 18 944 each). Equal.

Where else it engages: the parity IR census was stopped part way (owner order: no long gates): of 2 473
programs compared, 79 change IR, none of them changes output or compile rc. tssrc: no proven site lost or gained;
the three.js skin is byte-equal in every section.

Gates

qb10, on vs PERRY_FACTS_LOCAL_RANGES=0, build y1 = c96e5d4 (the PR head's code on main 7523aa7). The PR head 2cab0e0 adds only the merge
of main 6d3cd78 (clean; 11 commits in transform, EH and JSON), not re-measured. Pre-merge builds x1 / x2 where a row says so.

gate result
chess, instructions:u table above, two identical runs per arm
chess check mode (PERRY_VERIFY_FACTS=1) compile rc 0; perft 3, legal 1000, search 20000, evaluate 1000: rc 0, 0 misses (with the interval witness on every store into a proven int32 let)
chess IR / size IR differs in 1 of 2 modules (the engine). Every exec section on 7 604 694 B, off 7 609 000 B: −4 306 B (.text −80, perry_js_text −4 226)
S0 rc 0, 125 executable controls pass (local-range-negative: correct rc 0, sabotaged rc 86, predicate int32 input)
parity fixtures × {on, off, PERRY_REST_ELEMS=0, verify, GC schedule stress on, stress off} 13 fixtures × 6 arms, 78/78 MATCH node v26.8.1: number/test_facts_local_ranges (new), function/test_p2_signed_index_params (its at-rest board: form=i32 / form=rep on, form=f64 off), test_p2_signature_methods, test_p2_signature_functions, test_rest_capacity, test_rest_loops, test_rest_elems, test_rest_fields, test_rest_zero_sign, test_n1_int_temps, test_facts_int32_bindings, test_array_reps_fields, test_array_reps_loop_base_gc
GC schedule stress (instrumented runtime, PERRY_GC_SCHEDULE_SEED=7, RATE=1) the new fixture: 11 forced copying minors, 5 956 moved objects, on and off; test_p2_signed_index_params 25 / 4 396; the others as in #14450. Output equals node in every arm
parity IR census, on vs off stopped at 2 473 of about 2 603 programs (owner order): 79 IRDIFF, each with the same output; 0 compile-rc differences; 11 OUTDIFF, all with identical IR (main's non-deterministic set)
vf, PERRY_VERIFY_FACTS=1 n = 1 538, rc86 = 0, crc ≠ 0 = 15 (the same 15 as #14439 and #14450)
tssrc lost sites 64 169 site lines, 24 552 proven in both arms: lost 0, gained 0
hello / hello2 on vs off byte-identical (md5 f446a6b50365, d283d67fa8cc); callgrind Ir 133 150 / 642 287 in both arms
sizes chess −4 306 B (above); three.js skin 25 546 993 B on = off, every section equal; Supabase: not run (owner order)
unit tests (new) codegen facts_int32_locals::range_tests (IR): a_key_built_from_a_ranged_int32_let_is_an_i32_key, an_interval_whose_chain_leaves_int32_is_no_i32_key; perry compile_env: local_ranges_switch_is_a_cache_input
touched test modules codegen facts_int32_locals (with the new range_tests): 3 passed, 0 failed (Mac, dev profile, on the head's code before merging main). perry compile_env: not run for this PR. Full suites: not run (owner order 2026-11-11)
-D warnings perry-codegen --lib --tests: 0 errors (Mac, cargo check). Other crates: this PR adds one field to a codegen struct and one insert in the perry driver; not run
scripts/check_file_size.sh expr/mod.rs gains two lines (the int32_key module and one re-export): its allowlist count is set to its real count, 2 844 (it was stale on main, 2 842 vs 2 838). Every other file this PR touches is below the threshold. The script's other stale counts are main's

Not done

See FOLLOWUPS "[typed set follow-ups]":

  • a field read call of an at-rest Array in an int context (const old = this.at(square)) still tests ?? 0 on a
    double on the hit path;
  • this.hashA ^= x reads the I32 lane as a double and runs the guarded ToInt32 on it. Telling
    is_known_i32_range that an I32 lane read and a present at-rest element read are int32 changed nothing on chess
    (20 465.9 vs 20 466.4 M on search), so it is not in this PR: the compound assignment takes another path;
  • the Zobrist tables are ordinary Arrays; unmake's undo.captureSquare store keeps the double key (a double
    field).

Ralph Küpper added 30 commits November 11, 2026 10:13
…ot of any sign; probes # typed_call and PERRY_SIG_DIAG
# Conflicts:
#	crates/perry-codegen/src/expr/mod.rs
#	crates/perry/src/commands/compile/compile_env/tests.rs
#	scripts/test-verify-facts-s0.py
@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Explain Change →

Pre-merge checks: Inconclusive 2

Failed Failed checks: 2 inconclusive

Check name Status Explanation Resolution
Title check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
Description check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@proggeramlug
proggeramlug merged commit 8a6f93a into main Nov 11, 2026
94 of 102 checks passed
@proggeramlug
proggeramlug deleted the perf/int32-local-ranges branch November 11, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant